Skip to content

feat: render expanded chart at native resolution and add pinch-zoom via MultiGestureCanvas - #97698

Merged
luacmartins merged 31 commits into
Expensify:mainfrom
abbasifaizan70:92969-followup-zoom
Sep 16, 2026
Merged

luacmartins merged 31 commits into
Expensify:mainfrom
abbasifaizan70:92969-followup-zoom

Conversation

@abbasifaizan70

@abbasifaizan70 abbasifaizan70 commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

Re-renders the expanded chart natively at its target size (via a scaled chart context) instead of raster-upscaling the design-size render, so charts stay sharp on large screens.
Adds pinch/double-tap zoom and pan to the expanded chart using MultiGestureCanvas — the same gestures as the image attachment viewer, as agreed in the follow-up discussion on #95249.
Includes unit tests for the new context-scaling logic.

Fixed Issues

$ #92969
PROPOSAL:#92969 (comment)

Tests

  • Verify that no errors appear in the JS console
  1. Tap the expand button on the inline chart to open the full-screen modal.
  2. Verify the expanded chart is sharp on a large screen (text, axis labels, and lines are crisp, not blurry) and matches the inline chart's layout, only larger.
  3. Double-tap (or double-click) the chart and verify it zooms in; double-tap again to zoom back out.
  4. Pinch (mobile/trackpad) and verify the chart zooms smoothly within limits.
  5. While zoomed in, drag to pan around the chart and verify it stays within bounds.
  6. Close the modal and verify the chat is unchanged and no flicker appears.
  7. Repeat with a pie chart and verify zoom/pan work and the chart stays clipped/centered like inline.

Offline tests

Same as tests.

QA Steps

  • Verify that no errors appear in the JS console

Same as tests.

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Screen_Recording_20260825_014636_New.Expensify.Dev.mp4
Android: mWeb Chrome
Screen_Recording_20260825_015755_Chrome.mp4
iOS: Native
Screen.Recording.2026-08-25.at.12.38.37.AM.mov
iOS: mWeb Safari
Screen.Recording.2026-08-25.at.12.53.26.AM.mov
MacOS: Chrome / Safari
Screen.Recording.2026-08-22.at.5.33.50.AM.mp4

@codecov

codecov Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/CONST/index.ts 91.52% <ø> (ø)
...ctoryChartRenderer/context/VictoryChartContext.tsx 96.00% <100.00%> (+90.44%) ⬆️
...yChartRenderer/utils/computeExpandedChartLayout.ts 100.00% <100.00%> (ø)
...er/utils/getStaticChartCanvasProps/index.native.ts 100.00% <100.00%> (ø)
...enderers/VictoryChartRenderer/utils/parseOffset.ts 100.00% <100.00%> (ø)
...enderers/VictoryChartRenderer/utils/scalePixels.ts 100.00% <100.00%> (ø)
src/components/MultiGestureCanvas/index.tsx 1.20% <ø> (ø)
src/styles/index.ts 63.86% <ø> (ø)
...ryChartRenderer/components/VictoryChartContent.tsx 11.11% <0.00%> (ø)
...Renderer/context/VictoryChartRenderArgsContext.tsx 25.00% <50.00%> (ø)
... and 19 more
... and 8 files with indirect coverage changes

@abbasifaizan70 abbasifaizan70 changed the title feat: render expanded chart at native resolution and add pinch-zoom v… feat: render expanded chart at native resolution and add pinch-zoom via MultiGestureCanvas Aug 3, 2026
@abbasifaizan70
abbasifaizan70 marked this pull request as ready for review August 4, 2026 16:23
@abbasifaizan70
abbasifaizan70 requested review from a team as code owners August 4, 2026 16:23
@melvin-bot
melvin-bot Bot requested a review from situchan August 4, 2026 16:23
@melvin-bot

melvin-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

@situchan Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24d931aac7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@abbasifaizan70
abbasifaizan70 requested a review from a team as a code owner August 5, 2026 00:33
@luacmartins
luacmartins self-requested a review August 6, 2026 20:54
@situchan

situchan commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
android.mov
Android: mWeb Chrome
mchrome.mov
iOS: HybridApp
ios.mov
iOS: mWeb Safari
msafari.mov
MacOS: Chrome / Safari
web.mov

@luacmartins

Copy link
Copy Markdown
Contributor

@situchan how's review going?

@quinthar quinthar added the #ai label Aug 11, 2026
@luacmartins

Copy link
Copy Markdown
Contributor

@situchan any updates on the review?

@abbasifaizan70

Copy link
Copy Markdown
Contributor Author

@situchan Quick note on the new victory-native patch, since it's a judgment call I'd like your view on.

The two close bugs conflict: keeping the WebGL canvas mounted during the close animation causes the white flash, while unmounting it causes the blank card you reported. The clean fix is to render the expanded chart as a static bitmap via Skia's __destroyWebGLContextAfterRender, but victory-native doesn't expose its props — hence the small patch (a canvasProps passthrough, JS-only, no native changes).

If you'd rather avoid a patch, the alternatives are: (a) restore the isVisible gate and accept the brief blank card on close, or (b) make the close animation near-instant so neither shows. Both are compromises; the patch is the only one that fixes both properly. Happy to switch if you prefer one of those.

@situchan

Copy link
Copy Markdown
Contributor

Do you think the patch in #100714 fixes that bug or nothing related?

@abbasifaizan70

Copy link
Copy Markdown
Contributor Author

@situchan I don't think so —
#100714 only changes WebGLRenderer.dispose(), which in Skia 2.4.18 runs from the [tick] effect cleanup, i.e. only on unmount (or an Activity/StrictMode re-run). During our close animation the canvas stays mounted and no cleanup runs, so that code path isn't hit. It fixes a permanent blank after the context is lost, while ours is a transient flash while a live WebGL canvas is composited during the fade.

@abbasifaizan70

abbasifaizan70 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@situchan After adding the patch I rebuilt Android locally and re-tested everything end to end — double-tap now stops at the render cap, a small pinch after that no longer snaps back, swipe-down closes the modal, and closing shows no blank or flash. Demo videos attached below. If you still think any improvement, let me know i will take care for that.

Screen_Recording_20260911_182050_New.Expensify.Dev.mp4
Screen.Recording.2026-09-11.at.6.24.42_PM-compressed.mp4

@luacmartins

Copy link
Copy Markdown
Contributor

@situchan how's review going?

@luacmartins

Copy link
Copy Markdown
Contributor

@situchan any updates on this one?

// On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE
const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]);
// On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE — never past the allowed zoom range
const doubleTapScale = useMemo(() => Math.min(zoomRange.max, Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale)), [maxContentScale, minContentScale, zoomRange.max]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot this is global change. Won't this cause any regression on normal image attachment viewer?
i.e. double-tap might no longer fills for extreme-aspect images

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct, but no — not for any realistic image. The clamp only bites when double-tap-to-fill would need more than 20× zoom, and the attachment viewer never gets close.

doubleTapScale is maxContentScale / minContentScale, and getCanvasFitScale clamps both scales to ≤ 1. So fill > 20 requires the fitted image to render at under 1/20 of native in the constrained axis. On a ~390×700 dp canvas that means wider than 11.2:1 (and ≥ 7,808 px wide) or taller than 35.9:1 (and ≥ 14,014 px tall):

Image fill scale before → after
iPhone screenshot 1170×2532 1.21 3 → 3
Long screenshot 1170×20000 9.51 9.51 → 9.51
iPhone panorama 10000×2000 8.97 8.97 → 8.97
Banner strip 20000×500 51.2 51.2 → 20

A scrolling screenshot would have to be ~36× taller than wide before it even reaches 20×.

And for the degenerate ones the old value was never stable anyway: usePinchGesture onEnd springs zoomScale back down to zoomRange.max whenever it exceeds it — so a double-tap to 51× collapsed to 20× on the user's first pinch. That's the same snap-back you filed on BaseVictoryChartExpandedContent. The clamp just makes double-tap land where pinch would have parked it.

Worth noting the prop's own contract already claimed this: "Range of zoom that can be applied to the content by pinching or double tapping". On main, useTapGestures doesn't even receive zoomRange (props list) — it was never wired up, not deliberately excluded.

Per-consumer impact and threshold math

All four MultiGestureCanvas consumers:

Consumer zoomRange.max contentSize Effect of clamp
Lightbox (via ImageView) 20 (always — no caller overrides it) intrinsic image px only the >11:1 / >36:1 strips above
PDFView 5 contentSize === canvasSize → fill ≈ 1 none, doubleTapScale stays 3
MultiGestureIcon 20 360×360 square → fill ≈ 1 none, stays 3
BaseVictoryChartExpandedContent zoomHeadroom (1–3) fitted × headroom the intended fix

Why both scales ≤ 1 is the whole story: since maxScale ≤ 1, fill = maxScale / (minScale × 1.001) ≤ 1 / (min(sx,sy) × 1.001). So fill > 20 ⟹ min(sx,sy) < 0.04995. Combined with the canvas dimensions that gives the absolute pixel floors (7,808 / 14,014) and the aspect ratios (11.154 / 35.933) quoted above.

Desktop web is unaffected entirely — ImageView/index.tsx:87-96 only renders <Lightbox> when canUseTouchScreen(); the mouse path uses the DOM useClickZoomPan route and never touches useTapGestures. I verified this by reading the code rather than running a browser test, since the changed path is unreachable in a non-touch browser and a web run would prove nothing here.

One thing this does surface in your own chart path, though: when zoomHeadroom === 1, zoomRange.max is 1, so doubleTapScale becomes min(1, 3) = 1 and — because useTapGestures:162 only resets when zoomScale > 1 — double-tap becomes a silent no-op that re-runs zoomToCoordinates every time. The desktop path guards it explicitly with canZoom = layout.zoomHeadroom > 1, but BaseVictoryChartExpandedContent doesn't. Worth mirroring that guard.

There's also no test anywhere covering doubleTapScale or getCanvasFitScale — a small pure-function test over the four consumer configs would lock this behavior in cheaply.

@situchan

Copy link
Copy Markdown
Contributor

Not blocker but blue frame shows when close modal using Esc key.

Screen.Recording.2026-09-15.at.10.37.09.PM.mov

(and this is pre-existing inconsistency: normal attachment immediately closes when press down Esc key, but chart modal closes only when press up)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad13607151

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const scaledValue = scaleVictoryChartContextValue(value, scale, typeface);

// Release the Skia fonts created for this scale once they are replaced or unmounted
useEffect(() => () => disposeScaledFonts(scaledValue, value), [scaledValue, value]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recreate scaled fonts after effect cleanup

In development, the app is wrapped in React Strict Mode, which runs each newly mounted effect through setup → cleanup → setup without another render. The first cleanup here disposes every font referenced by scaledValue, but the second setup merely registers another cleanup and leaves the mounted provider holding those disposed fonts. Expanding a chart with axis labels can therefore render with invalid Skia font objects or fail on a subsequent redraw; manage creation and disposal so each effect setup owns fresh fonts, or otherwise avoid disposing the value that remains mounted.

Useful? React with 👍 / 👎.

@abbasifaizan70

Copy link
Copy Markdown
Contributor Author

Not blocker but blue frame shows when close modal using Esc key.

Screen.Recording.2026-09-15.at.10.37.09.PM.mov
(and this is pre-existing inconsistency: normal attachment immediately closes when press down Esc key, but chart modal closes only when press up)

@situchan Can we handle it here, or we can make new issue to handle it separately if this is a pre-existing inconsistency?

@situchan

Copy link
Copy Markdown
Contributor

(and this is pre-existing inconsistency: normal attachment immediately closes when press down Esc key, but chart modal closes only when press up)

@situchan Can we handle it here, or we can make new issue to handle it separately if this is a pre-existing inconsistency?

Not here

@abbasifaizan70

Copy link
Copy Markdown
Contributor Author

(and this is pre-existing inconsistency: normal attachment immediately closes when press down Esc key, but chart modal closes only when press up)

@situchan Can we handle it here, or we can make new issue to handle it separately if this is a pre-existing inconsistency?

Not here

I can work on that once new issue created.

@MelvinBot

Copy link
Copy Markdown
Contributor

No functional regressions in either. The victory-native patch applies cleanly (all 9 hunks), typechecks, and leaves the inline chart byte-for-byte unaffected. The ImageView → useClickZoomPan extraction is behavior-preserving — and notably it does not carry the double-scaling bug situchan found in the chart consumer. What I did find is a convention break in the patch and a few hygiene gaps.


Patch file

1. The three dist/*.js hunks are dead code and break the sibling-patch convention. The App never loads victory-native/dist/*.js — rsbuild aliases the package straight to source, with a comment saying exactly that (config/rsbuild/rsbuild.common.ts:171-172), and Metro/Bun resolve via the react-native condition. Only dist/*.d.ts is consumed, for types.

Patches 001 and 002 encode this correctly — I counted dist/*.js file hunks in each: 001 → 0, 002 → 0, 003 → 3. Nothing will catch drift between the inert dist/*.js copy and the live src/*.tsx copy, and every future version bump costs a pointless three-file conflict resolution. Recommend dropping the dist/*.js hunks and keeping dist/*.d.ts + src/**.

2. details.md violates the stated upstream policy. PATCHES.md:91 requires "if no upstream issue or PR exists, explain why". details.md:39 is a bare Not yet. — the only entry in the file without a reason (001 at least says "urgent patch with deadline"). scripts/validatePatches.sh only greps for the link, so CI won't flag it. Without a removal criterion the patch becomes permanent by default.

Patch areas verified clean
  • Applies: verified against the real installed victory-native@41.21.0 with 001+002 already applied — git apply --check and a real git apply both succeeded, all 9 hunks. No file overlap with 001/002, so patch-package ordering is safe.
  • dist/src symmetry: every change has its counterpart across src/, dist/*.d.ts, and dist/*.js. No asymmetry.
  • Spread ordering: {...canvasProps} is placed first, so ref/style always win. ChartWrapper sets none of the keys that survive the Omit, so nothing it relies on can be clobbered. (Two notes: the Canvas has no onLayout prop at all — it's on the wrapping View — and CanvasProps already omits onLayout, so that entry in the Omit is a no-op.)
  • Inline chart: receives canvasProps === undefined; {...undefined} contributes zero keys, so the props object is identical to pre-patch. Headless/server-CLI path early-returns before any Canvas.
  • __destroyWebGLContextAfterRender: present on CanvasProps in the installed Skia 2.4.18, so the Omit preserves it — npm run typecheck passes across all 5 projects with the patch applied. It doesn't leak to the DOM (Skia's web Platform.View destructures only children/onLayout/style).
  • Native safety: getStaticChartCanvasProps/index.native.ts returns undefined, so the flag never reaches native Skia.

Nit: Omit<CanvasProps, …> is looser than needed. One layer down, Skia spreads viewProps last, so a future caller passing nativeID, collapsable, or onSize would clobber Skia internals. Unreachable today, but a Pick<CanvasProps, '__destroyWebGLContextAfterRender'> would be tighter if this is upstreamed.

No test coverage: jest globally mocks victory-native with only Bar and CartesianChart (props ignored, PolarChart absent), and nothing in tests/ references canvasProps or getStaticChartCanvasProps. A future bump could silently drop the {...canvasProps} spread — typecheck still passes, no test fails, and the WebGL white-flash regression ships.


Image / receipt attachment modal

The extraction is clean. getScrollOffset and the drag state machine are copied verbatim, and zoomFactor: 1/zoomScale is algebraically identical to main's offsetX / zoomScale. ImageView passes the real inverse scale, so the chart's double-scaling bug does not apply to images. Small receipts are handled by getZoomSizingStyle, which this PR doesn't touch. Cursor styles, load/error/offline, and resetZoom wiring are all unchanged.

Three things worth your attention:

1. src/hooks/useClickZoomPan/index.native.ts is unreachable. Both importers are web-only platform files — ImageView/index.tsx:9 (native uses index.native.tsx, which never imports the hook) and VictoryChartExpandedContent/index.tsx:3 (whose index.native.tsx just re-exports the Base component). So the native no-op never runs — it's a type-level safety net only, not a live code path.

2. Stale seatbelt row. config/eslint/eslint.seatbelt.tsv:179 still claims ImageView/index.tsx has 1 @typescript-eslint/no-unsafe-type-assertion. The PR removed it — event.target as Node became event.target instanceof Node. Not a blocker (the lint workflow auto-commits a tightened baseline, and ESLint is green), but it would error in frozen mode.

3. No tests. All three new test files are chart-side. Nothing covers useClickZoomPan, ImageView, or doubleTapScale. The hook is now pure logic with injectable containerSize/zoomFactor, so getScrollOffset assertions are cheap.

Separately: the useTapGestures clamp does reach the touch/native image Lightbox, PDFView, and MultiGestureIcon — I worked through the blast radius in situchan's thread and it's inert for all three in practice. Since the MultiGestureCanvas diff reads as "+1 line", it's worth naming this explicitly in the PR description so a reviewer doesn't have to discover it.

One pre-existing gap I'll flag but won't call a regression: swiping the carousel doesn't reset zoom, because imageLoadingStart early-returns on isImageLoaded while isImageLoaded still reflects the previous image (ImageView/index.tsx:62). Identical on main. This PR is the natural place to fix it if you want to.


🤖 Web test steps verification results: ⚠️ partial

Wide-image zoom-to-click, zoom-out, small-image behavior, and no-zoom-state-retention all passed on the PR branch; drag-to-pan could not be exercised because agent-device has no drag or gesture support for web sessions. No console errors.

Wide image reopened un-zoomed

Step Status
Wide image (4000x1000) opens in the attachment modal, fits the viewport, fully visibleRendered the full landscape image letterboxed to the modal width, no cropping.
Wide image fitted and fully visible in attachment modal
✅
Click on center of the wide image zooms in, clicked point stays roughly in place (no jump to bottom-right corner)After clicking the visual center (a small boat wake in the fjord), the zoomed-in view kept that same content roughly centered on screen instead of scrolling to a corner.
Zoomed-in view centered on the clicked point
✅
While zoomed in, dragging pans the image and releasing does not toggle zoom offNot exercised: agent-device lists no drag/gesture/swipe/pan command for web; agent-device swipe and agent-device gesture pan both returned UNSUPPORTED_OPERATION. Tooling gap, not an observed product defect.
❌
Click once more zooms back out and re-fits the viewportSecond click on the same coordinates returned to the exact fitted view, pixel-identical to the initial fit screenshot.
Image zoomed back out to fitted view
✅
Small image (200x150) deeplink displays at a sensible size and clicking it behaves sanelyRendered centered at its natural small size; clicking zoomed in centered on the click point without jumping offscreen.
Small image at natural size
Small image zoomed in sanely after click
✅
Reopening the wide-image deeplink opens un-zoomed, not retaining earlier zoom stateFresh navigation to the same deeplink showed the fitted, non-zoomed view identical to the original open.
Wide image reopened un-zoomed
✅

File upload isn't drivable from this harness, so the viewer was opened via the /attachment?source=…&type=s deeplink instead. Drag-to-pan and the mWeb/native double-tap path still need a human pass.

@MelvinBot

Copy link
Copy Markdown
Contributor

Review: 1 blocker, 4 should-fix, rest is nits

Reviewed at ad13607. The chart scaling math is correct — I traced every pixel attribute end to end and each is scaled exactly once. The ImageView extraction is faithful — no behavior regression. The blocker is a Skia font lifetime bug, and it's invisible to the new tests.


🔴 Blocker — scaled Skia fonts are freed while the provider is still rendering with them

context/VictoryChartContext.tsx:103-106

const scaledValue = scaleVictoryChartContextValue(value, scale, typeface);  // allocates SkFont in render
useEffect(() => () => disposeScaledFonts(scaledValue, value), [scaledValue, value]);
return <VictoryChartContext.Provider value={scaledValue}>{children}</VictoryChartContext.Provider>;

Allocation happens in render, disposal in an effect cleanup with no setup. React does not guarantee a re-render between a cleanup and the next setup, so the cleanup frees the exact fonts scaledValue is still handing to the chart on the next line.

This is not theoretical in dev: StrictModeMountGate exists specifically to make screen content mount inside an already-committed StrictMode fiber so the double-invoke is real, and USE_ACTIVITY_SCREEN_STRICT_MODE_IN_DEV is true (CONFIG.ts:144). Expand a cartesian chart whose axis sets fontSize → setup → immediate cleanup → font.dispose() → VictoryChartCartesian.tsx:43 draws with a deleted CanvasKit handle. On unmount the same objects are disposed a second time. This is the same use-after-free class as the crash situchan recorded, even though the original getTypeface() cause is now correctly guarded.

Also worth checking in prod: screens are wrapped in <Activity mode={mode}>, and that file's own comments state hiding "would clean up its effects" — same cleanup, no re-render on reveal.

Fix: pair allocation and disposal on the same lifecycle. Either create the fonts inside the effect, or drop disposeScaledFonts entirely — every other Skia.Font(...) in this subsystem (VictoryChartLegend, VictoryChartLabel, computePieLabelLayout) is never disposed, so this PR introduces the only disposal path.

Why CI is green: the new tests deliberately avoid the font branch — scaleVictoryChartContextValueTest.ts:12-13 sets font: null, and VictoryChartScaledProviderTest.tsx:33 passes typefaces: {}, so Skia.Font(...) never runs and disposeScaledFonts has zero references in tests/.


🟠 Should-fix

1. The static renderer has no OffscreenCanvas feature detection. getStaticChartCanvasProps/index.ts:9 routes the expanded chart to Skia's StaticWebGLRenderer, whose only draw path is new OffscreenCanvas(...) at SkiaPictureView.web.js:117 — outside the try block. grep -rn OffscreenCanvas src/ returns nothing, so this is the App's first hard dependency on it, and there's no browserslist to tell me the supported floor. On Safari < 16.4 expanding any chart throws an uncaught ReferenceError. If OffscreenCanvas exists but WebGL2-from-OffscreenCanvas doesn't, the throw lands inside the try, draw() returns early, and the modal opens with a silently blank chart. Please feature-detect and fall back to the live renderer.

2. Every static draw leaks a CanvasKit surface. StaticWebGLRenderer.draw() calls cleanupWebGLContext(tempCanvas) but never surface.ref.delete() — the sibling makeImageSnapshot() does, in a finally. Each redraw (mount, font load, theme change, resize) leaks an SkSurface. Upstream defect, but this PR is what makes it reachable. Worth an explicit accept-or-fix decision plus an upstream issue, since the patch is already unreleased.

3. zoomHeadroom === 1 leaves the touch chart displaced with no way to recover. Desktop guards with canZoom = layout.zoomHeadroom > 1 (index.tsx:33); BaseVictoryChartExpandedContent.tsx:29 doesn't. doubleTapScale becomes 1, but zoomToCoordinates still runs its offset math — offsetY is zeroed at useTapGestures.ts:118-120, offsetX is not. A double-tap near the edge slides the chart off-centre with no zoom, and it can't be undone: the reset branch is gated on zoomScale > 1 (useTapGestures.ts:162) and pan needs zoomScale > 1 too. Mirror the canZoom guard. (Needs a ≥2048 dp touchscreen, hence should-fix not blocker.)

4. The riskiest extraction has no test. computeExpandedChartLayout, scaleVictoryChartContextValue and the scaled provider all got tests; useClickZoomPan/index.ts — 143 lines now owning the whole attachment-viewer click-zoom state machine — got none. A renderHook test over press-in → press (with and without a PointerEvent) → drag-release → resetZoom would cover it.


Verified clean (so you know what I actually checked)
  • ImageView extraction is faithful. Mechanical diff against main: offsetX * (1/zoomScale) === offsetX / zoomScale for all zoomScale > 0; the only divergence is zoomScale === 0, where the PR yields 0 instead of Infinity — an improvement, and unobservable since getZoomSizingStyle returns undefined with no image loaded. resetZoom is a strict superset of main's two setIsZoomed(false) sites and actually fixes a latent bug where a mid-drag waitForSession left isDragging stuck. Hook ordering is legal; StyleUtils args are byte-identical.
  • No listener churn from the useCallback removal. I compiled the hook with babel-plugin-react-compiler and read the emitted memo blocks — the compiler reproduces main's manual dep sets exactly (plus the stable scrollableRef). react-compiler-compliance-check → COMPILED (babel, oxc), non-divergent.
  • The earlier "series pixel attributes aren't scaled" bot comment is now stale. barwidth, cornerradius, strokeWidth, offset, pie radii, indicator dx/dy, axis lineWidth/labelOffset/font size, padding/domainPadding (incl. per-side), label/legend geometry are all scaled. BAR_INNER_PADDING, padangle and lineHeight correctly are not — they're fractions/angles/multipliers.
  • No double-scaling. Traced <victorybar barwidth="24"> from attribute → pixelScale → Skia: multiplied once. Only one VictoryChartScaledProvider in the tree, so no compounding. borderRadius is scaled outside the context and can't double-scale because useExpandedChartLayout runs outside the scaled provider.
  • The patch is complete and safe. Both dist/ (js + d.ts) and src/ are patched; both ChartWrapper call sites are threaded; the headless branch correctly needs none. {...canvasProps} spreads before ref/style, and onLayout is excluded twice over. A version bump can't silently drop it — applyPatches.ts treats any patch-package warning as a hard failure, and canvasProps isn't in stock types so typecheck fails too. details.md matches the existing entries.
  • Static canvas is expanded-only, inline chat charts keep the live renderer ({...undefined} no-op), native is a no-op by construction. The chart does not go blank after context destruction — draw() rebuilds the surface every call.
  • Desktop zoom now resets on close (index.tsx:43-48), and isVisible={false} correctly stops work on both platforms.
  • consistency-2 (magic values) is clean — MAX_ZOOM_HEADROOM imports DOUBLE_TAP_SCALE rather than re-literalling 3. consistency-16 (AI jargon), consistency-4, consistency-5, consistency-11 all clean.
  • The global useTapGestures clamp situchan asked about is safe for the image viewer — answered in that thread.
Code-quality nits (12)
  1. Dedupe is only half-applied. BaseVictoryChartExpandedContent.tsx:34-42 and VictoryChartExpandedContent/index.tsx:62-70 pass the same 7 props to ExpandedChartBox, including the duplicated derived providerScale={fitScale * zoomHeadroom}. Drift there is a silent mis-scale. Either pass layout whole, or add providerScale to ExpandedChartLayout where fitScale * zoomHeadroom is already computed (computeExpandedChartLayout.ts:77).
  2. parseCornerRadius.ts:16-22 scales inline ×4 while the object branch right below uses scalePixels. Inconsistent within one function.
  3. computeExpandedChartLayout.ts:59 and :68 spell out hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured twice — hoist it.
  4. Dead hasLayout guards in both platform variants (BaseVictoryChartExpandedContent.tsx:19-21, index.tsx:50-52) — VictoryChartExpandModal.tsx:85 already gates on it.
  5. scalePixels.ts:5 — the (number, number): number overload appears unused; every call site I checked passes number | undefined. Confirm, then delete.
  6. scaleVictoryChartContextValue.ts:10-15 — the file-header JSDoc is separated by a blank line and lands on scaleRecordValues instead of the function it describes.
  7. computeExpandedChartLayout.ts:39 — zoomHeadroom sits under /** Zoomed render size (fitted size × headroom) */, but it's the multiplier, not a size. Give it its own comment; it's the field every consumer reasons about.
  8. useClickZoomPan/types.ts — resetZoom is documented but onContainerPress isn't, and that's the one needing it: only the PointerEvent branch centers the zoom, the other two go to the origin.
  9. Em dashes / semicolons in new comments violate consistency-15-plain-comment-style: scalePixels.ts:2, scaleVictoryChartContextValue.ts:58, ExpandedChartBox.tsx:19,31, VictoryChartExpandedContent/types.ts:12, useTapGestures.ts:53, VictoryChartContext.tsx:32, useClickZoomPan/index.native.ts:3, VictoryChartExpandedContent/index.tsx:103.
  10. useClickZoomPan/index.ts:93 — the comment is the de-camelCased variable name (consistency-14). Delete it.
  11. VictoryChartExpandedContent/index.tsx holds a 113-line component plus the dispatcher, while its siblings (BaseVictoryChartExpandedContent.tsx, ExpandedChartBox.tsx) and the neighbouring VictoryChartContainer/ follow dispatcher-only index.tsx. Also scaleLabelItem is exported from scaleVictoryChartContextValue.ts against that utils/ dir's one-export-per-file convention.
  12. index.native.ts stub is unreachable — both consumers are web-only files with native siblings. Harmless today, but a future shared consumer would silently get isZoomed: false on native instead of a build error.

Two more, low priority: computeExpandedChartLayout.ts:53 treats Infinity as a valid design dimension (JSON5 parses it), producing NaN sizes with hasLayout: true; and VictoryChartPie.tsx:124's pre-existing useMemo can never hit because sliceValues/customLabelByDataLabel are fresh each render — this PR extends its dep array.

Test gaps beyond the hook

disposeScaledFonts has no test at all. scaleAxis with a real SkFont + typeface is untested, so nothing asserts the scaled size is original × scale or that the original font survives. pixelScale compounding only tests 1 → 2, so value.pixelScale * scale would pass if written as plain scale. Nothing ties providerScale to renderWidth / designWidth — the invariant that would catch a future double-scale. And no test covers series scaling (barWidth, cornerRadius, strokeWidth, pie radii), so the fix for the earlier bot complaint ships without a regression test.


I tried to verify the image-viewer zoom/pan behavior on web, but the test session white-screened during onboarding before any steps could run, so there's no runtime evidence in this review — the ImageView conclusions above come from a line-by-line diff against main plus a React Compiler output check, not from driving the app.

@luacmartins luacmartins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@luacmartins
luacmartins merged commit 5b419aa into Expensify:main Sep 16, 2026
44 of 49 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🚧 luacmartins has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/luacmartins in version: 9.4.81-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

MelvinBot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Yes — help site changes were required, and I've opened a draft PR: Docs updates for E/A#97698 expand and zoom Concierge spend charts

Why: this PR changes what a member can do with a Concierge spend chart after they open it full screen — the expanded chart is now re-rendered natively at its target size (so it stays sharp), and it supports zoom and pan. The help site documented only the inline chart (hover for a tooltip, select a bar to open its expenses in Search) and said nothing about the Expand icon or zooming, so neither behavior was discoverable.

What changed — docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md only:

  • New ## How to open a Concierge spend chart full screen section with separate web and mobile steps, since the two platforms differ. Web: hover to reveal Expand, click to zoom in on the clicked point, scroll or drag to pan, click again to zoom out. Mobile: Expand is always visible on a touchscreen, pinch or double-tap to zoom, drag to pan, swipe down to close.
  • Notes that the full-screen chart renders at full resolution, and that zooming does nothing once the chart already fills the screen at full resolution.
  • New ## Can I zoom in on a Concierge spend chart? FAQ entry, plus retrieval keywords.
How I verified the labels and behavior

Written against docs/HELP_AUTHORING_GUIDELINES.md, docs/HELPSITE_NAMING_CONVENTIONS.md, and docs/TEMPLATE.md: one workflow per section, #/## headings only, task- and question-based headings, bolded UI labels in sentence case, separate web and mobile instructions.

The labels came from the code in this PR, not from the live UI. A web test run couldn't produce a Concierge chart on the test account (Concierge returned no reply at all after ~4.5 minutes), so nothing was screenshot-confirmed. Please double-check these when you review:

I also checked for a duplicate — no existing docs PR covers chart expand or zoom. The nearest prior one, [No QA] Docs updates for E/A#96065 interactive Concierge spend charts, added the inline hover-and-select section this PR builds on.

One thing I couldn't do: GitHub rejected assigning you to the docs PR — abbasifaizan70 has read permission on Expensify/App, and GitHub only accepts assignees with push access. The HelpDot label and the PullerBear review request are both applied. Please assign yourself, or ask someone with write access to do it.

@abbasifaizan70, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review


view run · no recording available

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.81-1 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 failure ❌
🍎 iOS 🍎 failure ❌

1 similar comment
@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.81-1 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 failure ❌
🍎 iOS 🍎 failure ❌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants